#!/usr/bin/env python
"""Interact with files.wowace.com."""

import logging, os, re, urllib, urlparse, zipfile, cPickle, sys, platform
from StringIO import StringIO
from optparse import OptionParser
from xml.dom.minidom import parse, parseString
import openanything

__author__ = 'Kemayo (kemayo at gmail dot com)'
__version__ = '2.0.4'
__revision__ = '$Rev: 33 $'
__date__ = '2007/05/01'
__copyright__ = 'Copyright (c) 2007 Kemayo'
__license__ = 'GPLv2'

default_wowdir = False #Change this to, e.g. "F://World of Warcraft//Interface//Addons' if your wow directory is in a nonstandard location.
default_wowace = 'http://files.wowace.com/'
default_externals = False

openanything.USER_AGENT = 'wowacepy/%s +http://code.google.com/p/wowacepy/' % __version__

class wowace:
    """Interacts with files.wowace.com."""
    def __init__(self, wowdir = default_wowdir, wowace = default_wowace, externals = default_externals, logfile = False):
        if not wowdir:
            wowdir = get_wowdir()
        if not os.path.exists(wowdir):
            raise IOError, "World of Warcraft directory (%s) not found" % wowdir
        #set up logging
        if logfile:
            # Logging approach grabbed from: http://docs.python.org/lib/multiple-destinations.html
            logging.basicConfig(filename = logfile, filemode = 'a', level=logging.DEBUG, format='%(asctime)s %(levelname)-8s %(message)s',)
            console = logging.StreamHandler() # define a Handler which writes INFO messages or higher to the sys.stderr
            console.setLevel(logging.INFO)
            formatter = logging.Formatter('%(levelname)-8s %(message)s') # set a format which is simpler for console use
            console.setFormatter(formatter) # tell the handler to use this format
            logging.getLogger('').addHandler(console) # add the handler to the root logger
        else:
            # I might want to do something else here -- not sure.
            pass
        
        self.batchupdating = False
        
        self.wowdir = wowdir
        self.wowace = wowace
        self.externals = externals
        self.refresh()
    
    def refresh(self):
        """Refetch the local and remote addon listings."""
        self.localaddons = self.get_local_addons()
        self.remoteaddons = self.get_remote_addons()
    
    def update_addon(self, name, get_deps = True, unpackage = False, delete_old = True, force = False):
        """Update an addon.
        
        Required arguments:
        name -- name of the addon to update

        Keyword arguments:
        get_deps -- whether to fetch addon dependencies (default True)
        unpackage -- whether to unpackage addons that contain filelist.wau (default False)
        delete_old -- whether to delete the old addon directory before extracting the new one (default True)
        force -- whether to redownload the addon even if there isn't a new version (default False)
        
        If the addon directory contains '.svn' or '.ignore', nothing will be done.
        If the addon requested is not already installed, install it.
        save_local_addons will be called unless self.batchupdating == True.
        """
        if self.addon_can_be_updated(name, force):
            old_version = self.localaddons.get(name, 'unknown')
            new_version = self.remoteaddons[name]['version']
            if not os.path.exists(os.path.join(self.wowdir, name)):
                logging.info("Installing new addon: %s" % name)
            else:
                logging.info("Upgrading %s from %s to %s" % (name, old_version, new_version))
            zip = openanything.fetch(self.remoteaddons[name]['file'][0])
            if zip:
                zipdata = zipfile.ZipFile(StringIO(zip['data']))
                if diff_dir_zip(zipdata, self.wowdir):
                    logging.info("You'll need to restart WoW, or this addon might act up")
                    self.restart_required = True
                if delete_old:
                    removedir(os.path.join(self.wowdir, name))
                unzip(zipdata, self.wowdir)
                zipdata.close()
                self.localaddons[name] = new_version
                if get_deps:
                    for dep in self.remoteaddons[name]['dependencies']:
                        if not self.remoteaddons.has_key(dep) and not self.localaddons.has_key(dep):
                            logging.warning("%s has a dependency (%s) which is not installed locally and cannot be found on the server" % (name, dep))
                        else:
                            self.update_addon(dep, get_deps, unpackage, delete_old, force)
                if unpackage:
                    self.unpackage(name)
                self.save_local_addons()
            else:
                logging.warning("Upgrade failed!  Problem fetching/reading file.")
    
    def update_all(self, get_deps = True, unpackage = False, delete_old = True, force = False):
        """Update all addons"""
        self.batchupdating = True # Avoid pointlessly writing to disk on every update.
        for addon in self.addons_to_be_updated(force):
            self.update_addon(addon, get_deps, unpackage, delete_old, force)
        self.batchupdating = False
        self.save_local_addons()
    
    def addon_can_be_updated(self, name, force = False):
        """Checks whether an addon can be updated
        
        If force evaluates to True, this will return True regardless of the local and remote versions.
        This will ALWAYS return False if there's a .ignore or .svn path in the addon, or if the addon can't be found on the server.
        """
        if self.localaddons.get(name) in ('svn', 'ignore') or not self.remoteaddons.has_key(name):
            return False
        old_version = self.localaddons.get(name, 'unknown')
        new_version = self.remoteaddons[name]['version']
        if force or old_version != new_version:
            return True
    
    def addons_to_be_updated(self, force = False):
        addons = self.localaddons.keys()
        addons.sort() # This is functionally unnecessary... but it looks nicer on the output.
        return [addon for addon in addons if self.addon_can_be_updated(addon, force)]
    
    def unpackage_addon(self, name):
        """Follow the instructions in filelist.wau to unpackage an addon.
        
        See http://wowace.com/wiki/WowAceUpdater#The_Package_System for details of the package system.
        """
        if os.path.exists(os.path.join(self.wowdir, name+'.nounpack')):
            return
        filelist_path = os.path.join(self.wowdir, name, 'filelist.wau')
        if os.path.exists(filelist_path):
            self.batchupdating = True
            filelist_file = open(filelist_path)
            for package in filelist_file:
                package = package.strip()
                logging.info('Unpacking %s from %s' % (name, package))
                if package.startswith('@'):
                    package = package[1:]
                    package_destination = '%s_%s' % (name, package)
                else:
                    package_destination = package
                package_base = os.path.join(self.wowdir, name, package)
                package_destdir = os.path.join(self.wowdir, package_destination)
                if os.path.exists(package_base) and not os.path.exists(os.path.join(package_destdir, '.svn')) and not os.path.exists(os.path.join(package_destdir, '.ignore')):
                    removedir(package_destdir)
                    os.rename(package_base, package_destdir)
                    self.localaddons[package_destination] = 'package'
                    # WAU behavior is to immediately update and unpackage the unpackaged folders.
                    self.update_addon(package_destination, unpackage=True)
            filelist_file.close()
            self.batchupdating = False
            self.save_local_addons()
    
    def remove_addon(self, name):
        """Delete an addon, and remove its version information from localaddons."""
        path = os.path.join(self.wowdir, name)
        if os.path.exists(path) and not os.path.exists(os.path.join(path, '.svn')) and not os.path.exists(os.path.join(path, '.ignore')):
            removedir(path)
            del(self.localaddons[name])
            self.save_local_addons()
            logging.info("Removing %s" % name)
    
    def get_local_addons(self):
        logging.info("Loading local addons")
        if os.path.exists('addon_versions.pkl'):
            try:
                pickled_versions = open('addon_versions.pkl', 'rb')
                addons = cPickle.load(pickled_versions)
                pickled_versions.close()
                # We managed to load the list of addons.  Now, let's check to see whether any have been uninstalled...
                for addon in addons.keys():
                    if not os.path.isdir(os.path.join(self.wowdir, addon)):
                        # Addon directory is gone.  Drop version info.
                        del(addons[addon])
                        logging.info("Couldn't find %s, removing from saved versions" % addon)
            except EOFError:
                addons = {}
        else:
            addons = {}
        for addon in [f for f in os.listdir(self.wowdir) if os.path.isdir(os.path.join(self.wowdir, f))]:
            version = addons.has_key(addon) and addons[addon] or 'unknown'
            if os.path.exists(os.path.join(self.wowdir, addon, '.svn')):
                version = 'svn'
            elif os.path.exists(os.path.join(self.wowdir, addon, '.ignore')):
                version = 'ignore'
            elif not addons.has_key(addon):
                #Try to fall back to grabbing the changelog version.
                for f in os.listdir(os.path.join(self.wowdir, addon)):
                    m = re.search(r"[Cc]hangelog.*-r([0-9]+\.?[0-9]*)\.txt", f)
                    if m:
                        version = m.groups()[0]
                        break
            addons[addon] = version
        return addons
    
    def get_remote_addons(self):
        feed = urlparse.urljoin(self.wowace, self.externals and 'latest.xml' or 'latest-noext.xml')
        logging.info('Checking %s for updated addons' % feed)
        addons = waFeed(StringIO(openanything.fetch(feed)['data'])).addons
        if len(addons)==0:
            logging.warning('No addons found at %s' % feed)
        return addons
    
    def save_local_addons(self):
        if not self.batchupdating:
            pickled_versions = open(os.path.join(self.wowdir, 'addon_versions.pkl'), 'wb')
            cPickle.dump(self.localaddons, pickled_versions)
            pickled_versions.close()

class waFeed:
    def __init__(self, file):
        #file can be a local filename or an open file object.
        self.addons = {}
        d = parse(file)
        self.handle_rss(d)
        d.unlink()

    def handle_rss(self, rss):
        channel = rss.getElementsByTagName('channel')[0]
        self.handle_items(channel.getElementsByTagName('item'))

    def handle_items(self, items):
        for item in items:
            self.handle_item(item)

    def handle_item(self, item):
        self.addons[get_text_from_single(item, 'title')] = {
            'interface': get_text_from_single(item, 'wowaddon:interface'),
            'version': get_text_from_single(item, 'wowaddon:version'),
            'updated': get_text_from_single(item, 'pubDate'),
            'description': get_text_from_single(item, 'description'),
            'author': get_text_from_single(item, 'author'),
            'category': get_text_from_single(item, 'category'),
            'link': get_text_from_single(item, 'link'),
            'comments': get_text_from_single(item, 'comments'),
            'file': (item.getElementsByTagName('enclosure')[0].getAttribute('url'), item.getElementsByTagName('enclosure')[0].getAttribute('length')),
            'dependencies': tuple([get_text(dep.childNodes) for dep in item.getElementsByTagName('wowaddon:dependencies')]),
            'optionaldeps': tuple([get_text(optdep.childNodes) for optdep in item.getElementsByTagName('wowaddon:optionaldeps')]),
        }

#Stole this function wholesale from the python.org minidom example.
#The necessity of this function helps explain why I hate the DOM.
def get_text(nodelist):
    rc = ""
    for node in nodelist:
        if node.nodeType == node.TEXT_NODE:
            rc = rc + node.data
    return rc

def get_text_from_single(dom, tag):
    l = dom.getElementsByTagName(tag)
    if l:
        return get_text(l[0].childNodes)
    else:
        return ''

#This isn't a very thorough diff.  But it'll catch the main source of wow-restart-requiredness.
#TODO: Make this better. (Check for removed files as well, mostly.)
def diff_dir_zip(zip, path):
    for f in [f for f in zip.namelist() if not f.endswith('/') and not f.endswith('.txt')]:
        root, name = os.path.split(f)
        #The changelog filename, by virtue of containing the revision number, will alawys differ.
        if not name.startswith('Changelog'):
            #check for existance
            if not os.path.exists(os.path.join(path, f)):
                return True
    return False

def unzip(zip, path):
    for f in zip.namelist():
        if not f.endswith('/'):
            root, name = os.path.split(f)
            directory = os.path.normpath(os.path.join(path, root))
            if not os.path.isdir(directory):
                os.makedirs(directory)
            
            dest = os.path.join(directory, name)
            
            nf = file(dest, 'wb')
            nf.write(zip.read(f))
            nf.close()

def rmgeneric(path, __func__):
    try:
        __func__(path)
    except OSError, (errno, strerror):
        print "Error removing %(path)s, %(error)s " % {'path' : path, 'error': strerror }

def removedir(path):
    if not os.path.isdir(path):
        return
    
    files=os.listdir(path)

    for x in files:
        fullpath=os.path.join(path, x)
        if os.path.isfile(fullpath):
            rmgeneric(fullpath, os.remove)
        elif os.path.isdir(fullpath):
            removedir(fullpath)
    rmgeneric(path, os.rmdir)

def get_wowdir():
    #Try to guess the wow directory, based on platform.
    s = platform.system()
    if s == 'Windows':
        return os.path.join(os.environ['PROGRAMFILES'], 'World of Warcraft\\Interface\\Addons')
    elif s == 'Darwin':
        #macosx
        userdir = os.path.expanduser('~/Applications/World of Warcraft/Interface/Addons')
        if os.path.exists(userdir):
            return userdir
        else:
            return '/Applications/World of Warcraft/Interface/Addons'
    else:
        #We're screwed. Return a best guess, which might, maybe, work for linux. Though probably not.
        return '/usr/local/games/World of Warcraft/Interface/Addons'

# And now some functions that make this a standalone program, more or less.

def dispatch():
    # get_deps = True, unpackage = False, delete_old = True, force = False
    parser = OptionParser(version="%%prog %s (%s)" % (__version__, __revision__), usage = "usage: %prog [options] [addon1] ... [addon99]")
    parser.add_option('-e', '--externals', action='store_true', dest='externals', default = False,
        help="Download addons with externals")
    parser.add_option('-n', '--nodeps', action='store_false', dest='get_deps', default=True,
        help="Don't fetch dependencies")
    parser.add_option('-u', '--unpackage', action='store_true', dest='unpackage', default=False,
        help="Unpackage downloaded addons")
    parser.add_option('-k', '--keepold', action='store_false', dest='delete_old', default=True,
        help="Don't delete directories before replacing them")
    parser.add_option('-f', '--force', action='store_true', dest='force', default=False,
        help="Redownload all addons, even if current")
    parser.add_option('-r', '--remove', action='store_true', dest='remove', default=False,
        help="Remove addons passed as arguments")
    parser.add_option('--wowdir', dest='wowdir', default = default_wowdir and default_wowdir or get_wowdir(),
        help="Set the WoW addon directory [default: %default]", metavar="DIR")
    parser.add_option('--wowace', dest='wowace', default = default_wowace,
        help="Set the wowace file repository location [default: %default]", metavar="URL")
    options, args = parser.parse_args(sys.argv[1:])
    updater = wowace(wowdir=options.wowdir, wowace=options.wowace, externals=options.externals, logfile=os.path.join(options.wowdir, 'wowace.log'))
    if args:
        if options.remove:
            for arg in args:
                if updater.localaddons.has_key(arg):
                    updater.remove_addon(arg)
                else:
                    print "%s cannot be removed, as it cannot be found." % arg
        else:
            for arg in args:
                if updater.remoteaddons.has_key(arg):
                    updater.update_addon(arg, get_deps=options.get_deps, unpackage=options.unpackage, delete_old=options.delete_old, force=options.force)
                else:
                    print "%s cannot be found on the server." % arg
    else:
        updater.update_all(get_deps=options.get_deps, unpackage=options.unpackage, delete_old=options.delete_old, force=options.force)

if __name__ == "__main__":
    dispatch()

